fix: pin Readable.read() semantics to the compat matrix primary - #15
Merged
Conversation
O runtime estava METADE Node 24, METADE Node 26. A frente anterior (4edcb93) implementou a semantica do nodejs#60441 nos tres runtimes, mas sondou o node:stream local (26.8.1) antes de a doutrina do primario existir. O resultado: o read() pausado devolvia chunk-a-chunk (26) enquanto o async-iterator concatenava (24), e as fixtures 2845/2846 ficaram vermelhas contra o oraculo primario que o PR #14 pinou. Um binario compilado reproduz UM Node. Aplicando a doutrina merged, o runtime passa a implementar a semantica do PRIMARY (24.15) de forma coerente em toda a superficie de streams. Sondagem do Node 24.15.0 REAL, lado a lado com o 26.8.1 (matriz completa: bare read pausado/fluindo, read(n), com/sem decoder, unshift, iterador): as duas majors divergem em UMA regra so, howMuchToRead(NaN): Node 24: state.flowing && state.length ? head : state.length Node 26: !state.decoder ? head : state.length Ou seja, em 24 um read() PAUSADO colapsa a fila inteira e so o modo FLOWING caminha chunk a chunk; em 26 um stream sem decoder devolve a entrada da cabeca nos dois modos. Tudo o mais bate entre as majors: read(n) fatiando fronteiras, read curto esperando o EOF, read(n) exato, objectMode, read(0), os data events em flowing, Readable.from, e o iterador com decoder. E a regra unica se observa por tres caminhos (o read() direto, o dreno num handler 'readable' e o async-iterator), que e exatamente a incoerencia que estava aparecendo como "metade e metade". Revertido (era semantica 26): - scr_stream.c: o gate `flowing == 1 || !encoded` dos dois pontos de howMuchToRead (antes e depois do refill) volta a `flowing == 1`. - island-js/13-stream.js: o read() sem argumento volta a colapsar a fila (`_takeAll()`); read() ali e sempre o caminho PAUSADO, porque flowing entrega por _drainData()/_takeChunk(). - runtime-rust/readable.rs: o bare read volta a pedir `available` em vez do tamanho do chunk da cabeca (o `head` fica sem uso e sai). Mantido (ja batia com 24, verificado nas sondas): - read(n) curto que espera o EOF e so entao libera o restante, e o read(n) exatamente igual ao bufferizado que colapsa a fila. - read(NaN) tratado como a forma ausente. - unshift: a ordem LIFO e a insercao na frente nunca estiveram erradas — o que fazia o unshift PARECER vazado era o read() do outro lado. Marcado o ponto unico de mudanca: os tres runtimes carregam, na propria expressao, as duas formas (24 e 26) e a nota de que read() segue NODE_COMPAT_MATRIX.primary; ver nodejs#60441 para a migracao. Como ainda nao existe perfil de node:stream, o checklist de promocao do primario ficou no cabecalho de compat/node-matrix.ts, na secao nova "Semantics the primary DECIDES (not just labels)" — a distincao entre uma linha de censo (as duas respostas sao gravadas) e uma linha que o primario DECIDE (o binario so reproduz uma). Fixtures 2845/2846 reescritas contra a verdade do 24, cobrindo agora as DUAS metades da regra: o dreno pausado (que colapsa) e o flowing (que caminha), para que o gate nao possa ser achatado nem para um lado nem para o outro. 1746/2813 seguem verdes sem tocar. Prova de que o runtime e coerentemente 24: a mesma lane npm sob o oraculo 26 fica VERMELHA em exatamente stream-shims/paused-read e stream-shims/unshift, com o runtime emitindo as respostas do 24 ("b:lo world", "b:abc") contra as expectativas do 26 ("b:lo |world", "b:a") — a divergencia esta confinada ao nodejs#60441. Placar (vitest sob o Node 24.15 pinado, PATH incluido, porque npm.test.ts spawna um "node" nu como oraculo): differential C -t stream 45/45 e -t readable 8/8; rust-differential -t stream 45/45 e -t readable 8/8 (a lane Rust reivindicou 2845/2846, entao readable.rs rodou de fato); npm lane C 68/68 com stream-shims verde; npm-static 30/30; cargo test 139/139; clippy limpo; gen-island-bootstrap --check bate; lint 0 erros. Duas falhas fora do escopo (island "static hello-world size class" e surface-manifest "attestation-demoting libFn fence") ja falham identicas em origin/main, medido com o worktree limpo e rebuildado. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
O problema
O PR #14 pinou o oráculo diferencial no primário da matriz e, com isso, expôs uma incoerência que já estava no runtime: ele era metade Node 24, metade Node 26.
A frente anterior (
4edcb938) implementou a semântica do nodejs/node#60441 (semver-major, 26.0.0) nos três runtimes — mas sondou onode:streamlocal (26.8.1) antes de a doutrina do primário existir. O resultado ficou pela metade: oread()pausado devolvia chunk-a-chunk (26) enquanto o async-iterator concatenava (24); 2845/2846 vermelhas contra o oráculo primário; 1746/2813 fixando o pré-26.Um binário compilado reproduz UM Node. Aplicando a doutrina que o #14 mergeou, este PR faz o runtime implementar a semântica do PRIMARY (24.15) de forma coerente em toda a superfície de streams, e deixa o ponto de mudança documentado para quando o primário for promovido a 26.
Sondagem: Node 24.15.0 vs 26.8.1
Matriz completa rodada nos dois binários reais (bare read pausado/fluindo,
read(n), com/sem decoder, unshift depois de push e de read parcial, async-iterator). As duas majors divergem em UMA regra só,howMuchToRead(NaN):Em 24 um
read()pausado colapsa a fila inteira e só o modo flowing caminha chunk a chunk; em 26 um stream sem decoder devolve a entrada da cabeça nos dois modos.["aa","bb","cc"]"aabbcc", null"aa","bb","cc", nullread(3)de["hello ","world"]"hel","lo world", null"hel","lo ","world", nullread(3)cruzando fronteira, depois bare"aab","bcc", null"aab","b","cc"read(10)curto, antes do EOFread(10)curto, depois do EOF"aabb""aabb"read(4)exato"aabb""aabb"read(0), depois bare"aabb""aa"setEncoding)"aabbcc", null"aabbcc", nullread(3)com decoder, depois bare"hel","lo world""hel","lo world"{i:1},{i:2}, null{i:1},{i:2}, nullpush("bc"); unshift("a")"abc", null"a","bc", null"abc", null"a","b","c"read(3)parcial"hel","XYlo world""hel","XY","lo ","world""z", null"z", nullread(2)fatiando unshift"ab","cde", null"ab","c","de"dataevents (flowing)["aa","bb","cc"]["aa","bb","cc"]readable["aabbcc"]["aa","bb","cc"]["one two"]["one ","two"]["one two"]["one two"]Readable.from([...])/from("whole")["a","bc"]/["whole"]["t1 ","t2 "]["t1 ","t2 "]Duas leituras importantes:
read()bate entre as majors —read(n), o read curto que espera o EOF, o read exato, objectMode,read(0), osdataevents em flowing,Readable.from, o produtor lento, e o caso com decoder (26 continua concatenando quando háStringDecoder).read()direto, o dreno num handlerreadable, e o async-iterator. É exatamente por isso que a divergência aparecia como "metade e metade": não eram três bugs, era um só, visível de três ângulos.O que reverteu vs. o que manteve
Revertido (era semântica 26):
packages/runtime/src/scr_stream.c— o gateflowing == 1 || !encodeddos dois pontos dehowMuchToRead(antes e depois do refill) volta aflowing == 1.packages/runtime/src/island-js/13-stream.js— oread()sem argumento volta a colapsar a fila (_takeAll()). Aliread()é sempre o caminho pausado, porque flowing entrega por_drainData()/_takeChunk().packages/runtime-rust/src/readable.rs— o bare read volta a pediravailableem vez do tamanho do chunk da cabeça (oheadfica sem uso e sai).Mantido (já batia com 24, confirmado nas sondas acima):
read(n)curto que espera o EOF e só então libera o restante; eread(n)exatamente igual ao bufferizado, que colapsa a fila.read(NaN)tratado como a forma ausente.read()do outro lado.Ponto único de mudança
Os três runtimes carregam agora, na própria expressão, as duas formas (24 e 26) e a nota de que
read()segueNODE_COMPAT_MATRIX.primary, com o ponteiro para o nodejs#60441. Promover o primário a 26 é editar três expressões.Como ainda não existe perfil de
node:stream(só events/fetch/url, e o de fetch cobre Web Streams, não oReadable), o checklist de promoção ficou no cabeçalho depackages/compiler/src/compat/node-matrix.ts, numa seção nova — "Semantics the primary DECIDES (not just labels)". Ela nomeia a distinção que faltava: a maior parte da matriz é censo (um membro existe numa major e não na outra, e as duas respostas ficam gravadas), mas algumas linhas são de outra natureza — as duas majors dão respostas diferentes para a mesma chamada, e o binário só reproduz uma. Essas seguem o primário, e cada uma delas entra nessa lista para que uma promoção seja um checklist e não uma escavação.Prova de que o runtime é coerentemente 24
A mesma lane npm sob o oráculo 26 fica vermelha em exatamente dois cenários, com o runtime emitindo as respostas do 24:
A divergência está confinada ao nodejs#60441 — nada mais na superfície de streams separa as duas majors.
Fixtures
2845/2846 reescritas contra a verdade do 24, cobrindo agora as duas metades da regra: o dreno pausado (que colapsa) e o flowing (que caminha), para que o gate não possa ser achatado nem para um lado nem para o outro. 1746/2813 seguem verdes sem toque.
Removi de 2845 o caso de
read()com decoder: o compilador ainda não tem inspect lowering paraUint8Array | null(SC1090) e o runtime Rust lança emread()sobre stream com encoding. É o caso em que as duas majors concordam, então não carrega valor de pinagem — 1744-stream-set-encoding já o cobre.Placar
Tudo rodado com o vitest sob o Node 24.15 pinado, com o PATH incluído —
npm.test.tsspawna umnodenu como oráculo, então a lane só está pinada se o PATH estiver.-t stream-t readable-t stream-t readablereadable.rsrodou de fato)stream-shimsverdeDuas falhas fora do escopo — island
static hello-world stays in its size classe surface-manifestevery attestation-demoting libFn spelling is deniable by a manifest-id fence— já falham idênticas emorigin/main, medido com o worktree limpo (stash) e rebuildado.🤖 Generated with Claude Code
https://claude.ai/code/session_01L4tTZUEZzWnw3rHKVQDTMn